<?php

use Lin\Okex\OkexV5;
use Lin\Okex\OkexWebSocketV5;

require_once 'vendor/autoload.php';
require_once 'config.php';

/**
 *  OKEX模拟盘-单用户单币种量化机器人
**/
class Client
{
    public $okex;
    public $okex_socket;
    public $config;
    public $redis;
    
    public function __construct(array $config)
    {
        // 初始化/载入配置
        $this->init($config);

        // 频道订阅
        $this->okex_socket->subscribe([
            ["channel" => "positions", "instType" => "SWAP", "instId" => $this->redis->hget('config', 'currency')],
        ]);
        
        // 持续获取数据推送
        $this->okex_socket->getSubscribe([
            ["channel" => "positions", "instType" => "SWAP", "instId" => $this->redis->hget('config', 'currency')],
        ], function ($data) {
            // 机器人没有暂停 且 接口也通畅
            if (!$this->redis->exists('stop_robot') && !$this->redis->exists('rest')) {
                // 当前有仓位
                if (!empty(array_values($data)[0]['data'])) {
                    // 持续刷新持仓锁
                    $this->redis->setex('positionLock', 60, 1);
                    // 解析数据
                    $data = array_values($data)[0]['data'];
                    // 持仓处理
                    $this->orderHandle($data);
                // 当前没有持仓，且可以开单
                } else if (!$this->redis->exists('positionLock') && $this->redis->set('orderLock', 1, 'ex', 10, 'nx')) {
                    $this->writeln('近60秒无持仓，准备开单');
                    // 随机多空
					if (mt_rand(0, 100) > 50) {
						$this->createOrder('buy', 'long');
						$this->redis->set('direction', '做多');
					} else {
						$this->createOrder('sell', 'short');
						$this->redis->set('direction', '做空');
					}
                }
            }
        }, true);
    }
    
    // 持仓处理
    public function orderHandle(array $data)
    {
		// 多空双开时，不处理
		if (!empty($data) && count(array_column($data, 'posSide')) > 1) {
			return false;
		} else {
			// 没有多空双开 或 处理完毕时，删除key
			$this->redis->del('doubleError');
		}
		
        foreach ($data as $v) {
            // 简单校验数据有效性
            if (!empty($v['posId'])) {
                // 记录仓位信息
                $this->recordInfo($v);

                // 未实现USDT收益
                $v['upl'] = round($v['upl'], 2);
                // 未实现USDT收益率
                $v['uplRatio'] = round($v['uplRatio'] * 100, 2);

                // 止盈条件
                $condition1 = $v['uplRatio'] >= $this->redis->hget('config', 'profitRatio');
                // 止损条件
                $condition2 = $v['uplRatio'] < 0 && ((abs($v['uplRatio']) >= $this->redis->hget('config', 'stopLossRatio')));
                
				// ---------------------------------------止盈/止损/补仓---------------------------------------
                // 满足清仓条件（止盈/止损/补仓），且可以清仓
                if (($condition1 || $condition2) && $this->redis->set('closeLock', 1, 'ex', 10, 'nx')) {
					// 止损补仓
					// 倍投次数未超过限制，进行拦截补仓
                    if ($condition2 && ((int)$this->redis->get('addPositionNum') < $this->redis->hget('config', 'allAddPositionNum'))) {
						// ---------------------------------------动态补仓---------------------------------------
						$dynamicCoverResult = $this->dynamicCover($v);
						if ($dynamicCoverResult['status'] == false) {
							// 解清仓锁放下个请求进来，否则补仓颗粒度为10秒
							$this->redis->del('closeLock');
						    continue;
						}
						// ---------------------------------------动态补仓---------------------------------------

                        // 遇到正在补仓的操作
                        if (!$this->redis->set('addPositionLock', 1, 'ex', 10, 'nx')) {
                            $this->writeln('补仓正在进行中，本次跳过');
                            continue;
                        }
                        $this->writeln('现价'.$v['last'].'，补仓回调'.$dynamicCoverResult['info'].'%，准备补仓');
                        // 根据补仓策略计算补仓数量
                        $amount = $this->tacticsCalc($v['availPos']);
                        // 保持持仓方向补仓（createOrder里没有解清仓锁，等10秒后自动解吧，连续补仓情况应该极少，除非参数设置太难看）
                        if ($v['posSide'] == 'long') {
                            $this->createOrder('buy', 'long', $amount);
                        } else {
                            $this->createOrder('sell', 'short', $amount);
                        }
                        continue;
                    }
                    
                    // 止盈清仓
                    if ($condition1) {
						// ---------------------------------------动态止盈---------------------------------------
                        $dynamicStopResult = $this->dynamicStop($v);
                        if ($dynamicStopResult['status'] == false) {
                        	// 解清仓锁放下个请求进来，否则止盈颗粒度为10秒
                        	$this->redis->del('closeLock');
                            continue;
                        }
						// ---------------------------------------动态止盈---------------------------------------
						
                        $this->writeln('现价'.$v['last'].'，止盈回调'.$dynamicStopResult['info'].'%，准备清仓');
                    } else {
                        $this->writeln('达到止损条件，开始清仓');
                    }

                    // 清仓处理（里面有解清仓锁）
                    $this->closePosition($v['posSide']);

                // 不满足清仓条件
                } else {
                    // 清除上个收益率
                    $this->redis->del('cacheRatio');
                    // 清除首次达到止盈率
                    $this->redis->del('firstRatio');
                    // 清除反弹前的收益率
                    $this->redis->del('upBeforeRatio');
                    // 清除回调前的收益率
                    $this->redis->del('downBeforeRatio');
                    // 清除当前回调率/反弹率
                    $this->redis->del('downRate');
                }
				// ---------------------------------------止盈/止损/补仓---------------------------------------
            }
        }
        return true;
    }
    
    // 开单/补仓
    public function createOrder(string $side = 'buy', string $posSide = 'long', int $sz = 0): bool
    {
        // 随机开单才提示
        if ($sz == 0) {
            if ($side == 'buy' && $posSide == 'long') {
                $this->writeln('方向确定，开多');
            } else {
                $this->writeln('方向确定，开空');
            }
        }
        
        try {
			// 此处使用API下单，而不是websocket，确保稳定性
            $result = $this->okex->trade()->postOrder([
                'instId'    =>  $this->redis->hget('config', 'currency'),
                'tdMode'    =>  'cross',
                'side'      =>  $side,
                'posSide'   =>  $posSide,
                // 订单类型
                // market：市价单
                // limit：限价单
                // post_only：只做maker单
                // fok：全部成交或立即取消
                // ioc：立即成交并取消剩余
                // optimal_limit_ioc：市价委托立即成交并取消剩余（仅适用交割、永续）
                'ordType'   =>  'optimal_limit_ioc',
                // 非补仓读取首单数量(ETH * 100,)
                'sz'        =>  ($sz ?: $this->redis->hget('config', 'firstOrder')) / 10,
            ]);
            
            echo json_encode($result, true);
            
            // code转整数
            $result['code'] = (int)$result['code'];
            // 如果下单接口不通
            if (in_array($result['code'], [50001, 50004, 50013, 50026])) {
                // 休息60秒
                $this->redis->setex('rest', 60, 1);
                throw new Exception('接口不通，休息60秒');
            // 如果接口通畅，但补仓时余额不足
            } else if ($sz > 0 && $result['code'] == 51004) {
                // 增大补仓次数，让下次请求立即触发清仓
                $this->redis->set('addPositionNum', 9999999999);
                throw new Exception('余额不足，补仓失败，准备清仓');
            // 其他错误
            } else if ($result['code'] > 0) {
                // 其他错误，也休息60秒
                $this->redis->setex('rest', 60, 1);
                throw new Exception(json_encode($result['data'][0]['sMsg']));
            }
            
            // 今日做单次数+1
            $this->redis->hincrby('statistical', 'today_count', 1);
            // 记录本次下单/补仓数量
            $this->redis->set('lastNum', $sz ?: $this->redis->hget('config', 'firstOrder'));
            // 补仓成功
            if ($sz > 0) {
                $this->writeln('补仓成功，数量：'.$sz.' '.$this->redis->hget('config', 'currencyCoin'));
                // 补仓次数+1
                $this->redis->Incr('addPositionNum');
                // 解补仓锁
                $this->redis->del('addPositionLock');
                return true;
            }

            // 清除动态补仓、动态止盈数据
            $this->redis->del('firstRatio');
            $this->redis->del('cacheRatio');
			$this->redis->del('upBeforeRatio');
			$this->redis->del('downBeforeRatio');
			$this->redis->del('downRate');

            // 开单成功
            $this->writeln('开单成功，数量：'.$this->redis->hget('config', 'firstOrder').' '.$this->redis->hget('config', 'currencyCoin'));
        } catch (\Exception $e) {
            $this->writeln('报错：'.$e->getMessage());
            return false;
        }
        return true;
    }
    
    // 市价清仓
    public function closePosition($posSide): bool
    {
        try {
            $result = $this->okex->trade()->postClosePosition([
                'instId'    =>  $this->redis->hget('config', 'currency'),
                'posSide'   =>  $posSide,
                'mgnMode'   =>  'cross',
                // 如果不自动撤单，那有任何平仓挂单的情况下，市价全平会返回错误码信息，提示用户先撤销平仓挂单，默认false
                // 'autoCxl'   =>  true
            ]);
            
            // code转整数
            $result['code'] = (int)$result['code'];
            // 如果下单时接口不通
            if (in_array($result['code'], [50001, 50004, 50013, 50026])) {
                $this->redis->setex('rest', 60, 1);
                throw new Exception('接口不通，休息60秒');
            // 其他错误
            } else if ($result['code'] > 0) {
                // 其他错误也休息60秒
                $this->redis->setex('rest', 60, 1);
                throw new Exception(json_encode($result['data'][0]['sMsg']));
            }
            
            $this->writeln('清仓成功，等待下轮开单');
            // 解清仓锁
            $this->redis->del('closeLock');
            // 解补仓锁
            $this->redis->del('addPositionLock');
            // 清零补仓次数
            $this->redis->del('addPositionNum');
            // 清零上次下单数量
            $this->redis->del('lastNum');

            // 清除动态补仓、动态止盈数据
            $this->redis->del('firstRatio');
            $this->redis->del('cacheRatio');
			$this->redis->del('upBeforeRatio');
			$this->redis->del('downBeforeRatio');
			$this->redis->del('downRate');
			
			// 删除开单方向
			$this->redis->del('direction');
        } catch (\Exception $e){
            $this->writeln('报错：'.$e->getMessage());
            return false;
        }
        return true;
    }
    
    // 通过策略计算补仓数量
    public function tacticsCalc($value): float
    {
        // 获取补仓策略类型
        $tactics = $this->redis->hget('config', 'tactics');
        // 首单数量
        $firstNum = $this->redis->hget('config', 'firstOrder');
        // 获取上次补仓数量
        $lastNum = (float)$this->redis->get('lastNum');
        if ($lastNum <= 0) {
            $lastNum = $firstNum;
        }
        
        switch ($tactics) {
            // 倍投
            case 1:
                $amount = $lastNum * 2;
                break;
            // 等额
            case 2:
                $amount = $lastNum;
                break;
            // 默认倍投
            default:
                $amount = $lastNum * 2;
        }
        
        return $amount;
    }
	
	// 动态止盈
	public function dynamicStop($v): array
	{
	    // 标记首个收益率（实际有可能会大于止盈率，setnx设置后，后续无法再次覆盖）
	    $this->redis->setnx('firstRatio', $v['uplRatio']);
		
	    // 取出首个收益率
	    $firstRatio = (float)$this->redis->get('firstRatio');
		// 取出上个收益率
		$cacheRatio = (float)$this->redis->get('cacheRatio');
		
	    // 计算差额，判断收益率是否在持续增加
		// cacheRatio不存在则取firstRatio
	    $ratioDiff = $v['uplRatio'] - ($cacheRatio ?? $firstRatio);
		
		// 持续更新上个收益率
		$this->redis->set('cacheRatio', $v['uplRatio']);
	
	    // 如果收益率在持续增加
		// 或者回调率未超过阈值，收益率又掉头继续增加
	    if ($ratioDiff > 0) {
            // 如果回调前的收益率存在，且绝对值方面当前收益率超过了回调前的收益率，则删除回调前的收益率并设置为当前值
            if ($this->redis->get('downBeforeRatio') && (abs((float)$this->redis->get('downBeforeRatio')) < abs($v['uplRatio']))) {
                // 删除回调前的收益率(如果存在)
                $this->redis->del('downBeforeRatio');
                // 设置成当前值
                $this->redis->set('downBeforeRatio', $v['uplRatio']);
            }
            // 删除当前回调率/反弹率
            $this->redis->del('downRate');
			// 不处理仓位
	        goto label;
	    // 如果收益率开始回调
	    } else if ($ratioDiff < 0) {
			// 标记回调前的收益率（setnx设置后，后续无法再次覆盖）
			$this->redis->setnx('downBeforeRatio', $v['uplRatio']);
			// 取出回调前的收益率
			$downBeforeRatio = (float)$this->redis->get('downBeforeRatio');
			// 取出止盈回调率（阀值）
			$threshold = (float)$this->redis->hget('config', 'profitCallbackRatio');
			// 取出止盈率
			$profitRatio = (float)$this->redis->hget('config', 'profitRatio');
			
	        // 计算回调率 = 回调前的收益率 - 当前收益率
	        $dropRate = $downBeforeRatio - $v['uplRatio'];
			// 保存用于前端展示
			$this->redis->set('downRate', round($dropRate, 2));
	        // 如果回调率超过阈值，且当前收益率仍然大于止盈率，则立即止盈
	        if ((abs($dropRate) >= $threshold) && (abs($v['uplRatio']) >= $profitRatio)) {
				return [
					'status'	=>	true,
					'info'		=>	round($dropRate, 2),
				];
			}
	    }

        label:
		// 回调率未超过阈值/收益率没变等情况都不处理仓位
	    return [
	    	'status'	=>	false,
	    	'info'		=>	'',
	    ];
	}
	
	// 动态补仓
	public function dynamicCover($v): array
	{
	    // 标记首个收益率（实际有可能会大于补仓率，setnx设置后，后续无法再次覆盖）
	    $this->redis->setnx('firstRatio', $v['uplRatio']);
	    
	    // 取出首个收益率
	    $firstRatio = (float)$this->redis->get('firstRatio');
	    // 取出上个收益率
	    $cacheRatio = (float)$this->redis->get('cacheRatio');
	    
	    // 计算差额 = 当前收益率 - 上个收益率，判断收益率是否在持续降低
	    // cacheRatio不存在则取firstRatio
	    $ratioDiff = $v['uplRatio'] - ($cacheRatio ?? $firstRatio);
	    
	    // 持续更新上个收益率
	    $this->redis->set('cacheRatio', $v['uplRatio']);
	
	    // 如果收益率在持续降低
		// 或者反弹率未超过阈值，收益率又掉头继续降低
	    if ($ratioDiff < 0) {
            // 如果反弹前的收益率存在，且绝对值方面，且当前收益率超过了反弹前的收益率，则删除反弹前的收益率并设置为当前值
            if ($this->redis->get('upBeforeRatio') && (abs((float)$this->redis->get('upBeforeRatio')) < abs($v['uplRatio']))) {
                // 删除反弹前的收益率（如果存在）
                $this->redis->del('upBeforeRatio');
                // 设置成当前值
                $this->redis->set('upBeforeRatio', $v['uplRatio']);
            }
            // 删除当前回调率/反弹率
            $this->redis->del('downRate');
			// 不处理
            goto label;
	    // 如果收益率开始反弹
	    } else if ($ratioDiff > 0) {
			// 标记反弹前的收益率（setnx设置后，后续无法再次覆盖）
			$this->redis->setnx('upBeforeRatio', $v['uplRatio']);
			// 取出反弹前的收益率
			$upBeforeRatio = (float)$this->redis->get('upBeforeRatio');
			// 取出补仓回调率（阀值）
			$threshold = (float)$this->redis->hget('config', 'addPositionCallbackRatio');
			// 取出补仓率
			$stopLossRatio = (float)$this->redis->hget('config', 'stopLossRatio');
			
	        // 计算反弹率 = 当前收益率 - 反弹前的收益率
	        $riseRate = $v['uplRatio'] - $upBeforeRatio;
			// 保存用于前端展示
			$this->redis->set('downRate', round($riseRate, 2));
	        // 如果反弹率超过阈值，且当前收益率仍然大于补仓率，则立即补仓
	        if ((abs($riseRate) >= $threshold) && (abs($v['uplRatio']) >= $stopLossRatio)) {
                return [
                    'status'	=>	true,
                    'info'		=>	round($riseRate, 2),
                ];
            }
	    }
		
        label:
		// 反弹率未超过阈值或收益率没变等情况都不处理仓位
        return [
            'status'	=>	false,
            'info'		=>	'',
        ];
	}
    
    // 记录当前仓位基本信息
    public function recordInfo(array $data): bool
    {
        if (empty($data)) return false;
        
        // 开仓平均价
        $this->redis->hset('info', 'avgPx', round($data['avgPx'], 4));
        // 最新成交价
        $this->redis->hset('info', 'last', round($data['last'], 4));
        // 未实现收益
        $this->redis->hset('info', 'upl', round($data['upl'], 2));
        // 未实现收益率
        $this->redis->hset('info', 'uplRatio', round($data['uplRatio'] * 100, 2));
        // 杠杆倍数
        $this->redis->hset('info', 'lever', $data['lever']);
        // 爆仓价
        $this->redis->hset('info', 'liqPx', round($data['liqPx'], 2));

        return true;
    }
	
	// 日志操作与显示
	public function writeln(string $msg): bool
	{
	    // 显示换行
	    $msg = date('H:i:s', time()).'  '.$msg.PHP_EOL;
	    // 输出到cli
	    echo $msg;
	    // 消息入队列
	    $this->redis->lpush('robotLog', $msg);
	    // 队列只保存最新100条消息
	    if ($this->redis->llen('robotLog') > 100) {
	        $this->redis->rpop('robotLog');
	    }
	    return true;
	}
	
	// 初始化
    public function init(array $config)
    {
        $this->config = $config;
        $this->redis = new Predis\Client($this->config['redis']);
        
        $this->okex = new OkexV5($this->config['keysecret']['key'], $this->config['keysecret']['secret'], $this->config['keysecret']['passphrase']);
        // 模拟盘下单参数
         $this->okex->setOptions([
             'headers' => ['x-simulated-trading' => 1]
         ]);
		// 实例化websocket
        $this->okex_socket = new OkexWebSocketV5();
        // 传入API信息
        $this->okex_socket->keysecret($this->config['keysecret']);
        
        // 将机器人配置载入redis，方便动态配置
        // 初始资金
        $this->redis->hset('config', 'usdtInit', $this->config['other']['usdtInit']);
        // 止盈率
        $this->redis->hset('config', 'profitRatio', $this->config['other']['profitRatio']);
        // 补仓率
        $this->redis->hset('config', 'stopLossRatio', $this->config['other']['stopLossRatio']);
        // 最大补仓
        $this->redis->hset('config', 'allAddPositionNum', $this->config['other']['allAddPositionNum']);
        // 首单数量
        $this->redis->hset('config', 'firstOrder', $this->config['other']['firstOrder']);
        // 补仓策略
        $this->redis->hset('config', 'tactics', $this->config['other']['tactics']);
		// 交易对
        $this->redis->hset('config', 'currency', $this->config['other']['currency']);
		// 交易币种
		$this->redis->hset('config', 'currencyCoin', explode('-', $this->config['other']['currency'])[0] ?? 'ETH');
        // 补仓回调率
        $this->redis->hset('config', 'addPositionCallbackRatio', $this->config['other']['addPositionCallbackRatio']);
        // 止盈回调率
        $this->redis->hset('config', 'profitCallbackRatio', $this->config['other']['profitCallbackRatio']);
        // 二级标题
        $this->redis->hset('config', 'subTitle', $this->config['other']['subTitle']);
    }
}

new Client($config);